什么是响应式数据与副作用函数
响应式数据是指当数据发生变化时,能够自动触发相关操作的数据。副作用函数则是可能影响其他代码执行的函数。例如:
let val = 1
function effect() {
val = 2 // 修改全局变量,产生副作用
}
在上面的例子中,effect
函数修改了全局变量 val
,这会影响其他依赖 val
的代码,因此它是一个副作用函数。
再看一个更贴近 Vue.js 场景的例子:
const obj = { text: 'hello world' }
function effect() {
document.body.innerText = obj.text
}
这里,effect
函数读取了 obj.text
并设置到页面的 innerText
上。如果 obj.text
发生变化,我们希望 effect
函数自动重新执行,从而更新页面内容。这就是响应式数据的核心目标:当数据变化时,自动触发依赖它的副作用函数。
但普通 JavaScript 对象无法感知自身属性的读写操作,因此我们需要一种机制来拦截这些操作,Vue.js 3 使用了 ES2015+ 的 Proxy
来实现这一目标。
响应式数据的基本实现
要让 obj
变成响应式数据,关键在于拦截属性的读取和设置操作。具体来说:
- 读取时:记录调用该属性的副作用函数。
- 设置时:触发所有依赖该属性的副作用函数重新执行。
我们可以用一个 Set
集合(称为“桶”)来存储副作用函数,并通过 Proxy
拦截对象的读写操作。以下是一个简单的实现:
const bucket = new Set()
const data = { text: 'hello world' }
const obj = new Proxy(data, {
get(target, key) {
bucket.add(effect)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
bucket.forEach(fn => fn())
return true
}
})
function effect() {
document.body.innerText = obj.text
}
effect()
setTimeout(() => {
obj.text = 'hello vue3'
}, 1000)
在这个例子中:
- 当
effect
函数执行时,读取 obj.text
会触发 get
,将 effect
存入 bucket
。
- 1 秒后修改
obj.text
会触发 set
,从 bucket
中取出 effect
并重新执行,从而更新页面。
然而,这个实现有一个明显的问题:副作用函数被硬编码为 effect
,如果函数名改变或使用匿名函数,代码就会失效。接下来,我们需要改进设计,构建一个更灵活的响应系统。
构建完善的响应系统
为了解决硬编码问题,我们引入一个全局变量 activeEffect
来存储当前执行的副作用函数,并设计一个 effect
函数来注册副作用函数:
let activeEffect
function effect(fn) {
activeEffect = fn
fn()
}
同时,我们优化 Proxy
的实现:
const bucket = new Set()
const data = { text: 'hello world' }
const obj = new Proxy(data, {
get(target, key) {
if (activeEffect) {
bucket.add(activeEffect)
}
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
bucket.forEach(fn => fn())
return true
}
})
effect(() => {
document.body.innerText = obj.text
})
setTimeout(() => {
obj.text = 'hello vue3'
}, 1000)
这样,副作用函数无需固定名称,甚至可以是匿名函数。但新的问题出现了:如果设置一个未被读取的属性(例如 obj.notExist
),仍然会触发副作用函数执行,这是不必要的。
优化桶的数据结构
为解决上述问题,我们需要建立副作用函数与具体属性之间的精确联系。使用 WeakMap
、Map
和 Set
构建一个树形结构:
WeakMap
:以目标对象(target
)为键,值为一个 Map
。
Map
:以属性名(key
)为键,值为一个 Set
。
Set
:存储依赖该属性的副作用函数。
代码实现如下:
const bucket = new WeakMap()
const data = { text: 'hello world' }
const obj = new Proxy(data, {
get(target, key) {
if (!activeEffect) return target[key]
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
const depsMap = bucket.get(target)
if (!depsMap) return true
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
return true
}
})
使用 WeakMap
的好处是,当目标对象不再被引用时,它可以被垃圾回收器回收,避免内存泄漏。
封装 track 和 trigger
为了提高代码可维护性,我们将依赖收集和触发逻辑封装为 track
和 trigger
函数:
function track(target, key) {
if (!activeEffect) return
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
}
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
const obj = new Proxy(data, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
return true
}
})
这样,代码结构更清晰,且便于扩展。
处理分支切换与清理
在实际应用中,副作用函数可能涉及条件分支。例如:
const data = { ok: true, text: 'hello world' }
const obj = new Proxy(data, { /* ... */ })
effect(() => {
document.body.innerText = obj.ok ? obj.text : 'not'
})
当 obj.ok
变为 false
时,obj.text
不再被读取,但之前的依赖关系仍然存在,导致修改 obj.text
仍会触发副作用函数执行。为了解决这个问题,我们需要在副作用函数执行前清理旧的依赖关系。
为此,我们为副作用函数添加一个 deps
属性,存储所有相关的依赖集合,并在每次执行前清理:
let activeEffect
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
fn()
}
effectFn.deps = []
effectFn()
}
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
function track(target, key) {
if (!activeEffect) return
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
activeEffect.deps.push(deps)
}
这样,每次副作用函数执行前,cleanup
会移除旧的依赖关系,执行后重新建立新的依赖关系,避免不必要的更新。
避免无限递归
考虑以下场景:
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
effect(() => {
obj.foo++
})
obj.foo++
同时触发了 get
(读取 obj.foo
)和 set
(设置新值)。在 set
中,trigger
会再次调用副作用函数,而此时副作用函数尚未执行完毕,导致无限递归调用,栈溢出。
解决办法是在 trigger
中添加守卫条件,跳过当前正在执行的副作用函数:
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => effectFn())
}
这样,只有与当前执行的副作用函数不同的函数才会被触发,避免了无限递归。
支持嵌套副作用函数
在 Vue.js 中,组件渲染可能导致嵌套的副作用函数。例如:
effect(() => {
Foo.render() // 外层 effect
effect(() => {
Bar.render() // 内层 effect
})
})
为支持嵌套,我们引入一个 effectStack
栈,确保 activeEffect
始终指向当前执行的副作用函数:
let activeEffect
const effectStack = []
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.deps = []
effectFn()
}
这样,外层和内层副作用函数的依赖关系不会混淆,响应式数据只会与直接读取它的副作用函数建立联系。
可调度性:控制执行时机与次数
响应系统的可调度性允许开发者控制副作用函数的执行时机和次数。我们为 effect
函数添加 options
参数,支持自定义调度器:
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.options = options
effectFn.deps = []
effectFn()
}
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
例如,延迟执行副作用函数:
effect(
() => console.log(obj.foo),
{
scheduler(fn) {
setTimeout(fn)
}
}
)
或通过任务队列去重执行:
const jobQueue = new Set()
const p = Promise.resolve()
let isFlushing = false
function flushJob() {
if (isFlushing) return
isFlushing = true
p.then(() => {
jobQueue.forEach(job => job())
}).finally(() => {
isFlushing = false
})
}
effect(
() => console.log(obj.foo),
{
scheduler(fn) {
jobQueue.add(fn)
flushJob()
}
}
)
这种机制可以避免过渡状态的重复执行,优化性能。
计算属性与懒执行
计算属性是 Vue.js 的重要特性,它基于懒执行的副作用函数。我们通过 lazy
选项实现:
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
const res = fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
return res
}
effectFn.options = options
effectFn.deps = []
if (!options.lazy) {
effectFn()
}
return effectFn
}
function computed(getter) {
let value
let dirty = true
const effectFn = effect(getter, {
lazy: true,
scheduler() {
dirty = true
}
})
return {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
return value
}
}
}
为支持其他副作用函数依赖计算属性,我们在读取 value
时调用 track
,在 scheduler
中调用 trigger
:
function computed(getter) {
let value
let dirty = true
const effectFn = effect(getter, {
lazy: true,
scheduler() {
if (!dirty) {
dirty = true
trigger(obj, 'value')
}
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
track(obj, 'value')
return value
}
}
return obj
}
Watch 的实现
watch
是观测响应式数据的工具,其核心是基于 effect
和 scheduler
:
function watch(source, cb, options = {}) {
let getter = typeof source === 'function' ? source : () => traverse(source)
let oldValue, newValue
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: job
})
if (options.immediate) {
job()
} else {
oldValue = effectFn()
}
}
function traverse(value, seen = new Set()) {
if (typeof value !== 'object' || value === null || seen.has(value)) return
seen.add(value)
for (const k in value) {
traverse(value[k], seen)
}
return value
}
支持立即执行和控制执行时机:
function watch(source, cb, options = {}) {
let getter = typeof source === 'function' ? source : () => traverse(source)
let oldValue, newValue
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: () => {
if (options.flush === 'post') {
Promise.resolve().then(job)
} else {
job()
}
}
})
if (options.immediate) {
job()
} else {
oldValue = effectFn()
}
}
处理竞态问题
在异步场景中,watch
可能面临竞态问题。例如,多次修改数据触发多次请求,后发请求可能先返回,导致结果错误。Vue.js 通过 onInvalidate
解决:
function watch(source, cb, options = {}) {
let getter = typeof source === 'function' ? source : () => traverse(source)
let oldValue, newValue
let cleanup
function onInvalidate(fn) {
cleanup = fn
}
const job = () => {
newValue = effectFn()
if (cleanup) {
cleanup()
}
cb(newValue, oldValue, onInvalidate)
oldValue = newValue
}
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: () => {
if (options.flush === 'post') {
Promise.resolve().then(job)
} else {
job()
}
}
})
if (options.immediate) {
job()
} else {
oldValue = effectFn()
}
}
使用示例:
watch(obj, async (newValue, oldValue, onInvalidate) => {
let expired = false
onInvalidate(() => {
expired = true
})
const res = await fetch('/path/to/request')
if (!expired) {
finalData = res
}
})